Skip to content

feat: LocalStorage as a sparse override overlay#47

Closed
windischb wants to merge 5 commits into
developfrom
feature/localstorage-override-overlay
Closed

feat: LocalStorage as a sparse override overlay#47
windischb wants to merge 5 commits into
developfrom
feature/localstorage-override-overlay

Conversation

@windischb

@windischb windischb commented May 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Re-implements the LocalStorage provider as a sparse override overlay — a writable, application-controlled layer for overridable defaults — and builds the server side of browser-encrypted secrets on top of it. The normal sources (files, env, …) supply defaults; the application overrides individual values at runtime (including pre-encrypted secrets), and everything it does not touch keeps inheriting from the lower layers.

Supersedes #46 (the earlier full-object write model, which clobbered lower layers and could not express "only what I set overrides"). Built fresh off develop.

LocalStorage override overlay

Public API (Cocoar.Configuration.Abstractions)

  • ILocalStorage<T> — typed facade: SetAsync(x => x.A.B, v), ResetAsync, ClearAsync, ReadAsync, DescribeAsync, .Overlay
  • ILocalStorageOverlay<T> — raw JsonNode key-path surface for dynamic paths
  • OverrideEntry — per-key provenance DTO (base / effective / isOverridden)

Engine / DI plumbing

  • ConfigManager.BuildBaseJson — lock-free prefix merge (base = layers below the overlay) for casing alignment + provenance; deliberately avoids the recompute semaphore (reactive publish runs inside it → would deadlock a write-back subscriber)
  • IProviderServiceRegistration extended with resolve-time factory registration (BCL-only, no MS-DI dependency in the core package); both interfaces resolve to one shared singleton adapter

Semantics

  • Sparse writes (only touched leaves persist); byte-exact casing alignment to lower layers (no sibling keys)
  • Reset (inherit) vs. explicit null (clobber); default-valued overrides persist; arrays replaced wholesale
  • File backend hardened: per-write GUID temp + TOCTOU guard; store disposed via DI
  • Rewritten guide, runnable LocalStorageOverride example, changelog, README/sidebar rows

Browser-encrypted secrets (server side)

A client encrypts a secret with the server's public key and hands back only the ciphertext — the server never sees plaintext.

Write path

  • ILocalStorage<T>.SetSecretAsync(x => x.ApiKey, envelope) (typed) and ILocalStorageOverlay<T>.SetSecretEnvelopeAsync(keyPath, JsonNode) (raw) accept a pre-encrypted cocoar.secret envelope; plaintext / "***" is rejected
  • New SecretEnvelope<T> wire type (Abstractions); SetAsync rejects any value whose object graph contains a secret

Publish the encryption public key

  • ISecretEncryptionKeyProvider (DI: GetCurrentKeys / GetCurrentKey(kid)) + AspNetCore MapSecretEncryptionKeys / MapSecretEncryptionKeyByKid / MapSecretEncryptionKeyEndpoints publish the configured single-kid encryption public key as SPKI (base64url, no padding) at /.well-known/cocoar/encryption-keys
  • Exports the decryption-engine-preferred cert's public key only — the X509Certificate2 never leaves the inventory; resolved lazily so certificate rotation is reflected
  • Additive only: decryption, kid-folders, rotation, caching and the CLI are untouched. Multi-kid (per-tenant) publishing is deferred with the multi-tenancy effort

Design notes

  • No auto REST endpoints for writesILocalStorage<T> is DI-injectable; the app exposes its own write endpoint (controller / minimal-API / SignalR / gRPC) with its own validation, auth and tenancy, calling SetSecretEnvelopeAsync. The encryption-key endpoint is the only shipped HTTP surface: GET-only, default-open, chain .RequireAuthorization() to secure
  • D1–D4 honored: secrets locked behind envelopes, DescribeAsync in core, no [Obsolete] shim (built fresh), no SetManyAsync for now

Verification

  • Full solution Release build: 0 errors
  • Full test suite (excl. Performance): 661 passing, 0 failing — no regressions
  • Adversarial multi-dimension reviews on both the overlay and the secrets work; all confirmed findings fixed

Deferred / follow-ups (not in this PR)

  • Atomic multi-type write (WriteBatchAsync / ILocalStorage<(A,B)>)
  • C#-schema default as provenance fallback when no JSON layer sets a key
  • Browser client: TS lib @cocoar/secrets, cross-language round-trip test, demo app, user-facing docs
  • Multi-kid (per-tenant) encryption-key publishing (with multi-tenancy)

🤖 Generated with Claude Code

LocalStorage is now a writable, app-controlled layer for "overridable
defaults": lower layers (files, env, ...) supply defaults and the
application overrides individual values at runtime. Writes are sparse --
only the touched leaf is persisted; unset keys keep inheriting.

- Abstractions: ILocalStorage<T> (typed facade) + ILocalStorageOverlay<T>
  (raw key-path) + OverrideEntry (BCL-only)
- Sparse mutation with base-casing alignment, explicit-null vs reset, and
  empty-ancestor pruning; secret-typed members rejected (NotSupportedException)
- ConfigManager.BuildBaseJson: lock-free prefix merge for base/provenance
- IProviderServiceRegistration extended with resolve-time factory support
- DescribeAsync provenance (base / effective / isOverridden) for UIs
- File backend hardened (per-write temp + TOCTOU guard); store disposed via DI
- 31 tests; runnable LocalStorageOverride example; guide + changelog

No auto REST endpoints by design -- ILocalStorage<T> is DI-injectable so apps
write their own endpoints (validation/normalization/logging).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
windischb and others added 4 commits May 29, 2026 15:23
# Conflicts:
#	CHANGELOG.md
#	website/changelog.md
Server-side half of browser-encrypted secrets for LocalStorage:
- ILocalStorageOverlay<T>.SetSecretEnvelopeAsync(keyPath, JsonNode) stores a
  pre-encrypted cocoar.secret envelope; rejects plaintext and the "***" mask.
- ILocalStorage<T>.SetSecretAsync(selector, envelope) typed variant; the normal
  SetAsync still rejects secret members (no plaintext into the overlay).
- OverlayPathResolver gains allowSecretMembers for the secret-envelope path.

The envelope merges at the secret's key and decrypts on Secret<T>.Open() via the
normal read path -- plaintext never reaches the server.

E2E test (encrypt with a test cert -> SetSecretAsync -> recompute -> Open) confirmed
the runtime decrypt path requires base64url-WITHOUT-padding for wk/iv/ct/tag (NOT
standard base64) -- the byte-exact format the browser TS lib must emit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…et guard

- SecretEnvelope<T> (Abstractions): JSON-bindable wire form of an encrypted secret, so an
  API request DTO can carry it. The phantom T couples it to the target Secret<T> at compile
  time. It does NOT inherit from Secret<T> (different role: ciphertext-to-store vs openable
  secret). Binary fields are base64url-without-padding.
- ILocalStorage<T>.SetSecretAsync(Expression<Func<T, ISecret<TSecret>>> selector,
  SecretEnvelope<TSecret> envelope) -- selector target and envelope value type are matched
  by the compiler; one overload covers both Secret<TSecret> and ISecret<TSecret> members.
- SetAsync now rejects any value that is, or contains anywhere in its graph, a secret --
  preventing silent plaintext serialization / secret loss for whole-object writes.

Verified base64url-without-padding is the required wire encoding (standard base64 throws in
the runtime decrypt path) -- the exact format the @cocoar/secrets browser lib must emit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…endpoint)

Adds a read-only way to publish the configured single-kid encryption PUBLIC
key so external producers (browser, CLI, PowerShell, ...) can build
cocoar.secret envelopes the server decrypts — without ever holding the
private cert. This closes the missing server-side half of browser-encrypted
secrets (the key was previously only reachable by handing out the .pfx).

- Abstractions: ISecretEncryptionKeyProvider (GetCurrentKeys / GetCurrentKey),
  records SecretEncryptionPublicKey + SecretEncryptionKeySet, and SecretAlgorithms
  constants. SecretEnvelope<T>.Alg/Walg now reference the constants
  (value-identical, no wire change).
- Secrets: CertificateInventory.TryExportPreferredPublicKey() exports the
  decryption-engine-preferred cert's SPKI (public key only, under the write lock,
  the X509Certificate2 never leaks, transient load failures degrade gracefully).
  Internal ISecretEncryptionKeyInfoProvider is composed in single-kid mode only;
  SecretEncryptionKeyProvider resolves it lazily per call so rotation is reflected.
- DI: registers ISecretEncryptionKeyProvider only when a publishable key exists.
- AspNetCore: MapSecretEncryptionKeys / MapSecretEncryptionKeyByKid /
  MapSecretEncryptionKeyEndpoints; each returns IEndpointConventionBuilder so
  .RequireAuthorization() chains. Default-open like the flag endpoints.

Additive only: decryption, kid-folders, rotation, caching, the CLI and every
existing public API are untouched. Multi-kid (per-tenant) publishing is deferred
with the multi-tenancy effort — ApplyMultiKidMode is unchanged and folder mode
publishes an empty set for now.

Tests: round-trip (published SPKI -> encrypt with the public key only ->
Secret<T>.Open() recovers the value), endpoint list/by-kid/404/empty-set/auth
chaining, and JSON field-name pinning under a non-default naming policy.
Full suite 661/661 green; Release build 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@windischb

Copy link
Copy Markdown
Contributor Author

Superseded by the feature/multitenant branch. The sparse override overlay from this PR is fully included there (and extended with per-tenant LocalStorage), and will land in develop via that branch. Closing now to avoid a double-merge / confusion. The feature/localstorage-override-overlay branch is kept for reference and this PR can be reopened if needed.

@windischb windischb closed this May 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant